Skip to content

perf: optimize LEAD/LAG IGNORE NULLS evaluation#23711

Open
xudong963 wants to merge 1 commit into
apache:mainfrom
xudong963:perf-lead-lag-ignore-nulls
Open

perf: optimize LEAD/LAG IGNORE NULLS evaluation#23711
xudong963 wants to merge 1 commit into
apache:mainfrom
xudong963:perf-lead-lag-ignore-nulls

Conversation

@xudong963

@xudong963 xudong963 commented Jul 20, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

  • N/A

Rationale for this change

The whole-partition evaluation path for LEAD and LAG with IGNORE NULLS currently collects every valid row index, performs a binary search for every output row, converts every selected value to a ScalarValue, and finally rebuilds an Arrow array from those scalars.

This makes index selection O(n log m) for n rows and m non-null rows, and the per-row scalar materialization is particularly expensive for strings and nested values.

A local Criterion microbenchmark with 100,000 rows and 50% nulls measured the following speedups across the tested offsets:

Data type Speedup
Int64 6.7–6.9x
Utf8View 10.5–11.6x
List 21.7–21.8x

What changes are included in this PR?

  • Generate nullable Arrow gather indices with a single linear scan and bounded VecDeque state instead of collecting all valid indices and binary-searching for every row.
  • Materialize the result with Arrow's take kernel.
  • Use Arrow's zip kernel to fill non-null default values without constructing one ScalarValue per row.
  • Keep dictionary arrays with non-null defaults on scalar materialization because zip concatenates dictionaries and can overflow bounded dictionary key types.

The index generation and output materialization are now O(n). There are no public API changes.

Are these changes tested?

Yes.

Are there any user-facing changes?

No. This changes the implementation of whole-partition LEAD/LAG ... IGNORE NULLS evaluation without changing the SQL behavior or public API.

@github-actions github-actions Bot added the functions Changes to functions implementation label Jul 20, 2026
@codecov-commenter

codecov-commenter commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.82716% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.75%. Comparing base (1c8295c) to head (6711e7c).

Files with missing lines Patch % Lines
datafusion/functions-window/src/lead_lag.rs 93.82% 3 Missing and 7 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #23711    +/-   ##
========================================
  Coverage   80.75%   80.75%            
========================================
  Files        1089     1089            
  Lines      368809   368941   +132     
  Branches   368809   368941   +132     
========================================
+ Hits       297837   297945   +108     
- Misses      53217    53232    +15     
- Partials    17755    17764     +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xudong963
xudong963 marked this pull request as ready for review July 20, 2026 09:46
@xudong963
xudong963 requested review from Jefffrey and kosiew July 23, 2026 00:18
Comment on lines +484 to +485
// `zip` concatenates dictionaries, which can overflow bounded key types.
if matches!(array.data_type(), DataType::Dictionary(_, _)) && !default_value.is_null()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a limitation of zip or a bug with it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a current implementation bug/limitation in Arrow’s zip/MutableArrayData dictionary path, rather than an inherent limitation of zip semantics. It concatenates the complete backing dictionaries and can panic on bounded-key overflow even when the selected output would fit. interleave already has specialized dictionary merging/re-encoding logic. I’ll add a bounded-dictionary regression test as well.

let has_value =
is_not_null(&indices).map_err(|error| arrow_datafusion_err!(error))?;
let shifted: &dyn arrow::array::Array = shifted.as_ref();
zip(&has_value, &shifted, &default_value.to_scalar()?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if doing interleave instead of take + zip for the non-null default value path would be more efficient? or take+zip is plenty efficient enough 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, direct interleave looks preferable here. Including index construction, it was about 1.55x faster for Int64, 1.41x for Utf8View, and 3.81x for List in my tested workload. I’ll switch the non-null-default path to build the interleave indices directly, avoiding the intermediate take result and subsequent zip.

@xudong963
xudong963 force-pushed the perf-lead-lag-ignore-nulls branch from 613e6d3 to 6711e7c Compare July 23, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants